Skip to content

fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967

Open
ameyypawar wants to merge 3 commits into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel
Open

fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967
ameyypawar wants to merge 3 commits into
modelcontextprotocol:mainfrom
ameyypawar:fix/857-streamable-http-disconnect-cancel

Conversation

@ameyypawar

Copy link
Copy Markdown
Contributor

Fixes #857.

Motivation and Context

When a client connected over transport-streamable-http-server closes its TCP connection while a tool handler is still running, the server-side future was not cancelled — it ran to completion, and the per-request RequestContext::ct never fired. Explicit notifications/cancelled already worked; only the raw TCP-disconnect case was affected.

Root cause: the per-request cancellation token is only fired on a natural response (Event::ToSink) or an explicit notifications/cancelled. The request-wise SSE response stream is what observes the disconnect (the runtime drops it), but nothing wired that drop back to the token.

What this does

Wraps the request-wise SSE response stream returned by LocalSessionManager::create_stream in a small private drop-guard (CancelOnDisconnect):

  • It marks itself completed when the inner stream ends normally (the worker closes the request-wise sender once the response is delivered, or the stream is handed off for reconnection).
  • On drop-before-completion (client disconnect), it injects a synthetic notifications/cancelled for the request through the session's existing event channel. The service's existing cancellation path then fires the handler's RequestContext::ct — exactly as an explicit client cancellation would.

This reuses the existing cancellation machinery and adds no public API (the guard is private, and create_stream still returns impl Stream<Item = ServerSseMessage>).

How Has This Been Tested?

Added crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs (2 tests):

  • client_disconnect_cancels_in_flight_request: starts a long-running tool over streamable HTTP, drops the client connection mid-flight, and asserts the handler's cancellation token fires (it does, promptly).
  • normal_tool_response_is_delivered: a normal, fully-read tool call still returns unchanged (the guard does not spuriously cancel or corrupt the response).

Both pass locally. Also verified cargo +nightly fmt --all -- --check and cargo clippy are clean on the changed code.

Breaking Changes

None. Additive; no wire or public-API change.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Note

#857 has a comment from @loocor (2026-06-05, "I'd like to take this one"), but no PR followed in over a month, so I went ahead. Happy to defer or collaborate if they are still working on it.

…sconnect (modelcontextprotocol#857)

When a streamable-HTTP client closes its TCP connection while a tool
handler is still running, the request-wise SSE response stream is
dropped, but nothing fired the per-request cancellation token. Only a
natural response completion or an explicit notifications/cancelled
cancelled it, so a long-running handler ran to completion after the
client had already disconnected.

Wrap the request-wise response stream in a drop-guard. When the stream
is dropped before completing (client disconnect), it injects a synthetic
notifications/cancelled for the request, reusing the existing
cancellation path so the service fires the handler's RequestContext::ct.
Streams that end normally (response delivered, or a reconnection handoff)
do not cancel.

Adds a regression test covering both the disconnect-cancels and the
normal-completion paths. No public API change.
…elcontextprotocol#857)

Add the `[[test]]` entry so the new integration test is only built when
its required features (server, transport-streamable-http-server, reqwest)
are enabled, matching the other streamable-http test targets.
… ::new (modelcontextprotocol#857)

- Wrap the synthetic-cancellation construction to match `cargo +nightly
  fmt`.
- `ServerInfo` is `#[non_exhaustive]`; construct it in the test with
  `ServerInfo::new(..)` instead of a struct literal.

Verified locally: `cargo +nightly fmt --all -- --check` clean and the
regression test passes (disconnect cancels the in-flight request).
@ameyypawar ameyypawar requested a review from a team as a code owner July 9, 2026 19:11
@github-actions github-actions Bot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Jul 9, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, @ameyypawar! I left a couple of comments.

});
Ok(futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner)))
let stream = futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner));
Ok(CancelOnDisconnect::new(stream, handle.clone(), request_id))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also handle client disconnects after a request-wise SSE stream has been resumed? resume() currently returns a plain ReceiverStream without the disconnect guard.

// blocking. If the channel is momentarily full the request is not
// cancelled early, but the session keep-alive/idle timeout still reaps
// it — no worse than before this fix.
let _ = self.handle.event_tx.try_send(SessionEvent::ClientMessage {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make sure the disconnect cancellation isn’t silently dropped when the session event channel is full? Since the result of try_send is ignored, a busy session could still leave the handler running until cleanup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

streamable-http-server: client TCP disconnect does not cancel in-flight tool futures (RequestContext::ct never fires)

2 participants